Suppress speaker echo on the microphone channel - #113
Draft
alpha5611331 wants to merge 17 commits into
Draft
Conversation
Both getUserMedia call sites passed only a deviceId, so echoCancellation, noiseSuppression and autoGainControl ran on whatever Chromium currently defaults to. All three default to true today, so this changes no behaviour - it stops the behaviour changing on its own under a Chromium version bump, and gives the echo work one place to flip them from. AGC is split out as a named constant because it is the flag most likely to move: it raises gain through quiet passages, which amplifies re-captured interviewer audio on a speaker setup. The no-device case is an object with no deviceId rather than `audio: true`, which would have dropped the flags along with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measures how much of the interviewer's audio the microphone re-captures when the candidate is on speakers. Reports three numbers per machine: the signed arrival-order delay between the two channels, the correlation peak at that lag, and the echo return loss. The sign matters and is the reason the search window is two-sided. The acoustic path is always mic-after-speaker, but what is measured here is arrival order at the worklet, and Chromium's getDisplayMedia loopback path carries its own latency - so on a machine where it is the slower of the two, the reference arrives after the echo it explains. The window searched is wider than any gate would ship with, so a peak sitting at the edge is distinguishable from a window that is too small; the summary warns when that happens. An estimate is only accepted while the reference is actually active. A silent run reached a correlation of 0.53 - two noise floors correlate - so a peak height alone cannot tell coupling from silence. Manual, like taskbar-probe.mjs: it needs a desktop session, real speakers and someone to play audio into them, so it stays out of test/run.mjs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the correlator against synthetic signals turned up the more important defect. Delay recovery is exact, including the sign: +120, +300, -150, -250 and 0 ms all come back to the frame, and the ERL matches the injected gain. But the coupling verdict was wrong in the dangerous direction. The search takes the max over ~120 candidate lags, and the max of many correlations is biased upward, so unrelated signals score far higher than intuition suggests: 0.53 on pure silence, 0.57 on two independent bursty signals. A CORR_MIN of 0.5 calls both of those coupled, and a false "coupled" on a headphone user is what would lead a gate to cut a microphone that was never echoing anything. Prominence - the peak's height above the median lag - separates them cleanly: 0.28 for the unrelated pair against 0.87-1.13 for a real echo. Both ends of that gap are optimistic, so the threshold is a starting point to be re-derived from real runs, and the raw numbers are printed every second regardless. Also: reject unknown arguments and a non-positive --seconds. A mistyped --noaec was silently ignored, which runs with echo cancellation ON and reports a plausible number for the configuration you were trying to rule out; a non-numeric --seconds reached setTimeout as NaN and ended the run before it started, which reads like a headphone result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 28, 2026
Review of the probe turned up four ways it could report a confident number that was not true. The run-level verdict latched on a single coupled report, so one spurious second decided the headline finding for the whole run. That matters more after a live run reached a prominence of 0.47 in a silent room, against a threshold of 0.5 - the synthetic separation of 0.28 was optimistic, and a quiet room crosses that line occasionally. The verdict now counts coupled reports and refuses to call a run that cannot show several, reporting the fraction either way and saying INCONCLUSIVE rather than guessing. A stalled capture was invisible. push() simply stops being called, the report timer keeps firing, and the same numbers print every second looking exactly like a steady measurement. Frames are now counted and a report with no new frames says so, with a warning in the summary. getDisplayMedia was unbounded, so a loopback that never resolves left the probe sitting silently with no output. Bounded at 20s, the same as live-transcription.service.ts bounds it. And CORR_MIN now says explicitly that it is a floor rather than the discriminator - on its own it is the threshold already shown to be useless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stackoracle
marked this pull request as draft
August 28, 2026 01:51
Review of the branch, plus the merge of main, turned up six ways the work reported something that was not true. The merge is the substantive one. main added two more microphone captures while this branch was open: mock-transcription.service.ts, which inlines its own copy of the three processing flags, and the settings microphone test, which opens with `audio: true` and so takes whatever Chromium defaults to. Both textually merged clean and both defeat the point of the change - "one place to flip them from" is not one place if three callers spell it out themselves. micConstraints is exported and used by all three. The test meter case has a second edge: opened as `true`, the level shown while picking a device is measured through different processing than the session it exists to predict. In the probe: The stall guard counted the report it was about to refuse to print. `coupled` on a stalled report is the verdict of an estimate that ran against audio which is no longer arriving, so a capture that died while coupled kept voting on the headline finding - the exact thing the counters were added to stop. Both health checks now run before the counters. The frame counter cannot see an unplugged microphone, though the comment claimed it. The worklet is pulled by the destination for the life of the context and zero-pads a missing input by design, so frames keep arriving at 100/s after a track ends while the columns decay quietly toward the noise floor. Ended tracks are reported separately and named. `muted` is deliberately not used - it toggles on ordinary silence on some platforms and would discard most of a legitimately quiet run. The first report latched `stalled` permanently. lastFrames starts at 0, so a report that lands before the graph produces its first frame looks exactly like a capture that died, and put a "re-run this" warning on the summary of a run that then went fine. Before the first frame is now distinguished from after the last one. A suspended AudioContext produced a silent run with no explanation: no frames, a table of blanks, and a summary reading "no coupling" - the headphone verdict, from a probe that never listened. Resumed if suspended. "No coupling" could print directly underneath a non-zero count of accepted coupled estimates. Estimates run twice a second and reports are sampled once, so intermittent coupling can enter `samples` without a report tick landing on it. The headphone verdict now has to clear both counters. Also: a loopback with no audio track is called out at startup rather than left to be inferred from an empty ref% column, `--device` lists the labels it did not match (an exact match against long parenthesised OS strings is the whole difficulty), `--no-ns` is documented alongside the other two flags, and the stall warning no longer prints a mangled duplicate of its own second line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on running it A test for the invariant the merge already broke once. `main` grew two more microphone captures while this branch was open, one with the three processing flags copied out and one opening with `audio: true`, and both merged clean - there is no conflict, no type error and no lint warning in adding a second spelling of a constraint object, which is why a checker has to be the thing that notices. `mic-constraints.test.mjs` walks src/ and fails on any capture that does not open through micConstraints, naming the file. It fails, verified, on exactly the regression that reached main. Only src/ is scanned: the probe varies those flags on purpose, which is its job. Then the probe, from the point of view of someone actually running it. Closing the window mid-run ended the process at exit 0 with no summary and nothing said - indistinguishable, in a scrollback, from a run that finished. It now says what happened and exits non-zero. A failed window load rejected into nothing: the window sat at "starting...", no report ever arrived, and the probe waited for a run that would not begin. The likeliest failure in this tool is a mistyped --device, whose message is a list of the device names that do exist - and it was printed under ten frames of Electron internals. Failures the operator can fix are marked, and print their message alone. The loopback timeout is one of them, and now says what to check rather than just that it timed out. The run asks a person to play audio and stay quiet for 45 seconds while the only thing moving is a console table. The window counts down the seconds left, and says when the summary is ready. Also recorded, not corrected: the correlation overlap shrinks as |lag| grows, so lags at the edges of the search are estimated from ~20% less audio and the peak leans very slightly outward. It is ~0.02 against a noise floor that has measured 0.47-0.57, and it cannot reach the "peak sits at the edge" warning, which only prints for runs that already have coupled estimates. Correcting it would move every number the gate is about to be sized from, on judgement rather than on data, so it is written down where whoever picks the window will read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run where every report was discarded - all stalled, all on a dead capture - or one too short to produce a single report reached the summary with totalReports at zero and printed "no coupling (headphones, or nothing played through them)". That is the worst version of the failure the rest of this summary is built to avoid: the headphone verdict, stated confidently, from a probe that took no valid reading at all. It now says so and names both causes. And the probe was undiscoverable. `taskbar-probe.mjs` is documented in the section it belongs to; this one was in no section at all, so the next person to wonder how much of the interviewer the microphone re-captures had no way to find the tool that answers it. The Headphones section now carries it, together with the constraints rule and the test that pins it. CLAUDE.md is edited by hand rather than through prettier: running prettier on it rewrites 29 unrelated emphasis markers, which is the churn the file's own Commands section warns about. This change is purely additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe rejects a mistyped `--noaec` because a silently dropped flag runs with echo cancellation ON and reports a plausible number for the configuration you were trying to rule out. The same thing happens one layer down and was not handled: these constraints are advisory, so Chromium is free to decline them and say so only in getSettings(). Requested and applied were both printed and any disagreement left for the reader to spot, in a tool whose entire job is scoring that A/B - and unlike the typo, this one is not the operator's fault. Now called out. A device that reports nothing back is said to report nothing, rather than being read as agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on-zero Both found by running the probe rather than reading it. The first is the same class as everything else in this summary. A run where the loopback reference never carried audio played nothing for the microphone to re-capture, so it measured nothing about coupling - and the verdict called that "no coupling (headphones, or nothing played through them)", offering the two as equal readings while the ref% column had already told them apart. A run only measures coupling if something was coupling-capable in the first place. The silent case now says so and says what to do; the headphone verdict now means what it says, audio played and the mic did not pick it up. The second is worse for being invisible. `process.exitCode = 1` followed by `app.quit()` does not survive - Electron ends the process through its own path and the status comes out 0 - so every failure the probe reported came back as success to the shell. A `--device` that does not exist printed its error, its list of real device names, and exited 0. Both the error paths and the window-closed-early path went through it. `app.exit` carries the code. Verified end to end on Windows 11: a run exits 0, a bad --device exits 1 with its message intact through a pipe, and the four argument guards still exit 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing the reader to edit source Four findings, all from running the probe against real audio rather than reading it. The coupled path had never actually executed before this. **A false "coupled" with the microphone silent.** A run accepted an estimate at correlation 0.62 and prominence 0.61 with mic% at 0 and an ERL of -56 dB. That is not an echo 56 dB down, it is the correlator finding structure in a noise floor, at a lag pinned to the edge of the search window. The thresholds cannot catch it on their own, because the surface really does have a sharp peak - and a false "coupled" on a HEADPHONE user is the expensive direction, which is the entire reason prominence exists. An estimate can only describe re-captured audio if the microphone recorded any, so that precondition is now required outright. Real coupling on the same machine runs mic% 24-45 against a floor of 5, and a re-run confirms the verdict still lands at coupled 8/16. **The search window needed a source edit.** The summary's answer to a peak at the edge was "widen MIN_LAG_MS/MAX_LAG_MS in renderer.js and re-run". That warning is not exotic: it fired on the first machine measured, whose peak sat at -400, the exact floor of the default. Completing a measurement should not mean editing a renderer file, so the window is `--min-lag=` / `--max-lag=`, validated like the rest, and the warning now names the flag and the value to re-run with. Re-run at --min-lag=-1200 the peak lands at -280 and the warning correctly stops firing. **The summary and the table describe different populations.** Medians cover accepted estimates only, which run twice per printed row, so the summary reported a median correlation of 0.67 when no printed row exceeded 0.53. Read side by side that looks like an error rather than a better-filtered number. Said explicitly now. **INCONCLUSIVE gave advice that did not fit.** It said "re-run with audio playing for the whole duration" to a run whose ref% sat between 72 and 85 the entire time - sending someone to redo the thing they had already done correctly. A reference that was solid throughout means the coupling is marginal on that machine, which is a finding, not a mistake, and the two cases now get their own remedy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Run each twice and compare erlDb" is not enough method, and the first machine measured shows why: two 16 s runs of the same unchanged configuration came back -36.0 and -38.8, a spread of 2.8 dB, while the aec on/off pair differed by 4.3 dB. Two runs cannot separate a real effect from that, and the instruction as written would have someone record a difference that is mostly noise - in the one number the constraints decision gets made on. The header now says to establish the unchanged configuration's spread first and to discard any difference smaller than it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making --min-lag/--max-lag settable opened a new way to get the silent wrong answer this branch keeps closing. The correlator holds 4 s of history and needs 500 ms of overlap at every candidate lag, so a bound beyond +/-3500 ms leaves correlateAt below its minimum at every lag: it returns null for all of them, no estimate is ever produced, and the summary reports "no correlated frames" - the headphone answer, from a window that was too wide to search rather than a mic that heard nothing. Checked in the renderer, where the history and overlap numbers are defined, and raised as a ProbeError so it prints the arithmetic and no stack. --min-lag=-5000 now explains itself and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The edge warning suggests a wider search, and the previous commit taught the probe to refuse a window beyond what the correlator can search. On a window already near that limit the two disagreed: the summary would name a --min-lag the next run exits 1 on. The suggestion is clamped to the limit now, and when there is no room left to widen it says that instead - the delay is a lower bound at that point, and resolving it needs a longer history rather than a wider window. The limit travels with the summary from the renderer, where it is derived, rather than being restated here and left to drift. Checked across the five cases: peak at the floor and at the ceiling of the default window, peak at each end of a window already at the limit, and a peak mid-window that warns about nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"No correlated frames. Either this is a headphone setup (the good case), or no audio was playing" was written when that line was the whole answer. It is not any more: the verdict beneath it now distinguishes NOTHING MEASURED, NOTHING PLAYED, no coupling and INCONCLUSIVE, using the ref% history and the report counts. So the line offered "the good case" as a live possibility directly above a verdict saying nothing had been measured at all - the same unearned confidence this branch has been removing everywhere else, in the one place that used to be entitled to it. It states the fact and stops, in the same label/value shape as the populated branch. Interpreting is the verdict's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md said `ingest()` "deduplicates overlapping segments". Nothing does. `mergeAdjacentTranscripts` concatenates consecutive blocks from the SAME speaker inside TRANSCRIPT_INTER_TRANSCRIPT_GAP_MS, and no code anywhere compares the two channels against each other - grepped for it to be sure. Worth more than a wording fix, because the file already contradicted itself: the Headphones section a few hundred lines down says the transcript duplicates on speakers and that suppressing it is what #111 is for. A reader who took line 63 at face value would conclude the echo duplication was already handled, which is the exact opposite of the problem this branch exists to measure, and would have no reason to look further. Listed on this PR's own checklist and cheap to settle now that the code was being read anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331
marked this pull request as ready for review
September 7, 2026 12:39
alpha5611331
marked this pull request as draft
September 7, 2026 16:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #111.
The branch for the whole echo-suppression feature, landing in the order the work has to happen: the measurement first, because every constant in the gate is sized from it, and measuring after the fact means tuning twice.
Landed so far
1. One place for the microphone processing constraints
echoCancellation,noiseSuppressionandautoGainControlare stated rather than left on Chromium's implicit defaults.micConstraints()inlive-transcription.service.tsis the single spelling, and everygetUserMediain the app goes through it.This changes no behaviour today - all three already default to
true. The point is that they stop moving on their own under a Chromium version bump, and that there is one place to flip them from once the probe says which way they should go. AGC is a named constant because it is the flag most likely to move: it raises gain through quiet passages, which amplifies re-captured interviewer audio on a speaker setup.The no-device case is an object with no
deviceIdrather thanaudio: true, which would have dropped the flags along with it. The flags are plain (advisory) values, notexact, so there is no newOverconstrainedErrorpath.test/mic-constraints.test.mjspins it. That is not defensive habit: see the merge section below.2. A manual probe for the coupling
test/manual/echo-probe.mjsdrives a real Electron session and reports, once a second:It also drives the A/B the constraints work exists for:
Manual, like
taskbar-probe.mjs: it needs a desktop session, real speakers and someone to play audio into them, so it stays out oftest/run.mjs. It is not packaged either -build.filesships onlyelectron-dist,distandpackage.json, so the probe'snodeIntegrationnever reaches a release. It is documented in CLAUDE.md's Headphones section, where the problem it measures is described.What the merge with main found
mainmoved 41 commits ahead while this branch was open, and merged clean. It also silently defeated the change, which is the part worth reading.Two more microphone captures had been added.
mock-transcription.service.tsinlined its own copy of the three flags; the settings microphone test opened withaudio: true, which drops them entirely - the exact pattern this branch exists to remove. Neither produces a conflict, a type error or a lint warning, because there is nothing wrong with writing a constraint object.The
audio: trueone has a user-visible edge of its own: it is the mic test meter, so the level shown while choosing a device was measured through different processing than the session that level is meant to predict.Both now go through
micConstraints(), andtest/mic-constraints.test.mjswalkssrc/and fails on any capture that opens its own way, naming the file. Verified to fail on exactly the regression that reached main. Onlysrc/is scanned - the probe varies those flags on purpose.What building and reviewing the probe found
Every one of these is a case of the tool reporting a confident number that was not true, which is the only failure mode that matters in an instrument the rest of the feature is tuned against.
The correlator is right, including the sign. Injected delays of +120, +300, -150, -250 and 0 ms all come back exact to the frame, and the ERL matches the injected gain (0.1 -> -20.0 dB). The two-sided search is not theoretical: the acoustic path is always mic-after-speaker, but what is measured is arrival order at the worklet, and Chromium's
getDisplayMedialoopback path carries its own latency, so on some machines the reference arrives after the echo it explains.Peak height cannot tell coupling from noise. The search takes the max over ~120 candidate lags, and the max of many correlations is biased upward. Measured: 0.53 on pure silence, 0.57 on two independent bursty signals. A
CORR_MINof 0.5 - which looks perfectly reasonable written down, and is what the design started with - calls both of those coupled. That error runs in the dangerous direction: a false "coupled" on a headphone user is what leads a gate to cut a microphone that was never echoing anything.Replaced with peak prominence, the peak's height above the median lag. A real echo puts a sharp peak on an otherwise flat surface; unrelated signals produce a surface that is uniformly mediocre with a high maximum and no peak.
And the prominence threshold has less margin than the synthetic numbers implied. Synthetics gave 0.28 unrelated against 0.87-1.13 for echo. A live run in a silent room reached 0.47, against a threshold of 0.5. So the run-level verdict counts coupled reports rather than latching on the first, and reports the fraction either way, saying
INCONCLUSIVErather than guessing.A mistyped flag was silently ignored.
--noaec(a plausible slip for--no-aec) ran with echo cancellation ON and reported a credible number for the configuration you were trying to rule out. Unknown arguments and a non-positive--secondsnow exit 2. A non-numeric--secondspreviously reachedsetTimeoutasNaN, ending the run before it started, which reads like a headphone result.And the same failure one layer down. These constraints are advisory, so Chromium may decline one and say so only in
getSettings(). Requested and applied were both printed with any disagreement left for the reader to spot - in the tool whose whole job is scoring that A/B, and unlike the typo, not the operator's fault. A mismatch is now called out, and a device that reports nothing back is said to report nothing rather than read as agreement.getDisplayMediawas unbounded, so a loopback that never resolves left the probe silent with no output. Bounded at 20s, the same aslive-transcription.service.ts.Ways the verdict could still be stated on nothing
A second review pass, all in the same class as the above.
Discarded reports still voted. The counters incremented before the stall check, so a capture that died while coupled kept feeding the headline finding the counters exist to protect. Both health checks now run first.
The frame counter cannot see an unplugged microphone, though its comment claimed it. The worklet is pulled by the destination for the life of the context and zero-pads a missing input by design, so frames keep arriving at 100/s after a track ends while the columns decay quietly toward the noise floor. Ended tracks are now detected and named separately.
readyState === 'ended'is the signal andmuteddeliberately is not - it toggles on ordinary silence on some platforms and would discard most of a legitimately quiet run.The first report latched the stall flag permanently.
lastFramesstarts at 0, so a report landing before the graph's first frame looked identical to a dead capture and stamped a "re-run this" warning on a run that then went fine.A suspended
AudioContextproduced a silent run with no explanation: no frames, a table of blanks, and a summary reading "no coupling" - the headphone verdict, from a probe that never listened. Resumed if suspended."No coupling" could print under a non-zero coupled-estimate count. Estimates run twice a second, reports sample once, so intermittent coupling can enter
sampleswith no report tick landing on it. The headphone verdict now has to clear both counters.A run with no valid report at all still got a verdict. Every report discarded, or a run shorter than the report interval, reached the summary at zero and printed "no coupling (headphones...)". It now says nothing was measured, and names both causes.
A loopback carrying no audio track is called out at startup rather than left to be inferred from an empty
ref%column forty lines later.And two more, found by running it rather than reading it
A silent run was read as headphones. A run where the loopback reference never carried audio played nothing for the microphone to re-capture, so it measured nothing about coupling - and the verdict called that "no coupling (headphones, or nothing played through them)", offering the two as equal readings while the
ref%column had already told them apart. The silent case now says so and says what to do, and the headphone verdict now means what it says: audio played, and the mic did not pick it up.Every failure exited 0.
process.exitCode = 1followed byapp.quit()does not survive - Electron ends the process through its own path and the status comes out 0 - so every failure the probe reported came back to the shell as success. A--devicethat does not exist printed its error, printed its list of real device names, and exited 0. Both error paths and the window-closed-early path went through it.app.exitcarries the code.And four more, once real audio was driven through the loopback
The coupled path had never actually executed before this. Driving a bursty reference through the speakers ran it, and it found:
A false "coupled" with the microphone silent. An estimate was accepted at correlation 0.62 and prominence 0.61 with
mic%at 0 and an ERL of -56 dB. That is not an echo 56 dB down, it is the correlator finding structure in a noise floor, at a lag pinned to the edge of the search window. The thresholds cannot catch it, because the surface really does have a sharp peak - and a false "coupled" on a headphone user is the expensive direction, which is the entire reason prominence exists. An estimate can only describe re-captured audio if the microphone recorded any, so that precondition is now required outright. Real coupling on the same machine runsmic%24-45 against a floor of 5, and a re-run confirms the verdict still lands at coupled 8/16.The search window needed a source edit to widen. The summary's answer to a peak at the edge was "widen
MIN_LAG_MS/MAX_LAG_MSin renderer.js and re-run". That warning is not exotic: it fired on the first machine measured, whose peak sat at -400, the exact floor of the default. Completing a measurement should not mean editing a renderer file, so the window is now--min-lag=/--max-lag=, validated like the rest, and the warning names the flag and the value to re-run with.The summary and the table describe different populations. Medians cover accepted estimates only, which run twice per printed row, so the summary reported a median correlation of 0.67 when no printed row exceeded 0.53. Side by side that reads as an error rather than a better-filtered number. Said explicitly now.
The search window could be widened past what the correlator can search. Making the window settable created a new way to get the silent wrong answer: a lag further from zero than the 4 s history can cover leaves every candidate below the minimum overlap, so no estimate is produced at all and the summary reports "no correlated frames". Rejected now, with the arithmetic, and the widen-the-window suggestion is clamped so it can never name a window the probe would then refuse.
INCONCLUSIVEgave advice that did not fit. It said "re-run with audio playing for the whole duration" to a run whoseref%sat between 72 and 85 the entire time, sending someone to redo what they had already done correctly. A reference that was solid throughout means the coupling is marginal on that machine, which is a finding rather than a mistake, and the two cases now get their own remedy.First numbers off a real machine
Not the A/B the checklist wants - one machine, and a synthetic bursty reference rather than a recorded interview - but the rig ran, and two things came out of it that matter to the plan.
applied: aec=falsecomes back from the device under--no-aec, which is the constraints change verified end to end rather than assumed.erlDbmedian--no-aecTwo runs of the same configuration differ by 2.8 dB while the configurations differ by 4.3 dB. So "run each twice and compare
erlDb", which is what this PR's own instructions said, cannot separate a real effect from run-to-run spread, and would have someone record a difference that is mostly noise in the one number the constraints decision gets made on. The header now says to establish the unchanged configuration's spread first and discard any difference smaller than it.The delay is negative on this machine (median -280 ms), which is the signed-lag case the two-sided search was built for, confirmed on hardware rather than argued from first principles. And the default window was genuinely too narrow for it.
For the person running it
The window could be closed mid-run, ending the process at exit 0 with no summary and nothing said - indistinguishable, in a scrollback, from a run that finished. A failed window load rejected into nothing, leaving the window at "starting..." waiting for a run that would not begin. The likeliest error in this tool is a mistyped
--device, whose message is a list of the device names that do exist, and it was printed under ten frames of Electron internals; failures the operator can fix now print their message alone. The run asks a person to play audio and stay quiet for 45 seconds while the only thing moving is a console table, so the window counts down and says when the summary is ready.Notes recorded in the code rather than acted on
Pacing. The meter paces its cross-correlation on
performance.now(). Fine for a live capture at 100 frames/s, but it makes the class untestable from synthetic input, because a test loop feeds thousands of frames in a few milliseconds and no interval ever elapses.echo-gate.tsneeds unit tests, so it should pace on a frame counter instead.Overlap bias. The correlation overlap shrinks as
|lag|grows (400 frames at lag 0, 320 at +80), so edge lags are estimated from ~20% less audio and the peak leans very slightly outward - on the order of 0.02, against a noise floor that has measured 0.47-0.57, and it cannot reach the "peak sits at the edge" warning, which only prints for runs that already have coupled estimates. Correcting it would move every number the gate is about to be sized from, on judgement rather than on data, so it is written down where whoever picks the window will read it.Still to come on this branch
echoCancellationandautoGainControlon 2-3 machines; recorddelayMs(with sign),correlation,prominenceanderlDb. One machine's numbers are above, against a synthetic reference; they are a shakedown of the rig, not the result. Note the run-to-run spread before designing the rest.AudioContext, a two-input worklet, and a delay-aligned energy gate onch_1(audio-capture-graph.ts+ a dependency-freeecho-gate.ts), plus theaudio-device-switch.test.mjsrewrite.ingest(), gated on time overlap (echo-dedup.ts).herodocs and an entry on speaker vs headphone capture insrc/renderer/pages/documentation/index.tsx(the olddocumentation-dialog.tsxthis checklist named was renamed in Upgrade the client app UX: launch hub, first-run setup, Account/Configuration split, hint-only default #125, and the page currently has no headphone entry at all). CLAUDE.md is done: the probe and the constraints rule are documented in Headphones, and the stale "deduplicates overlapping segments" claim is corrected - nothing deduplicates, which is the gap that claim was hiding.Verification
eslint,tsc -b,tsc -p tsconfig.electron.json,vite buildandpnpm test:mainall green - the same set CI runs.test/mic-constraints.test.mjsverified against a deliberate regression: reverting the mic test toaudio: truefails two checks and names the file.NOTHING PLAYEDrather than guessing at headphones, and exits 0.--bogus,--noaec,--seconds=abc,--seconds=0), all exit 2.--devicethat does not exist prints its message and the real device labels, with no stack trace, and exits 1 with the output intact through a pipe.coupled (speakers)at 8/16, correctly reportsNOTHING PLAYEDwhen silent, and the--min-lag/--max-lagguards reject a non-numeric or inverted window with exit 2.test/echo-gate.test.mjsonce the gate exists and is testable, which is what the pacing note above is about.Supersedes #112, which GitHub closed when the branch was renamed.